.NET: Fix declarative workflow regressions for hosted agents#5905
.NET: Fix declarative workflow regressions for hosted agents#5905alliscode wants to merge 6 commits into
Conversation
Three regressions surfaced when running a declarative workflow as a Foundry hosted agent. Together they caused every condition group to fall through to elseActions and the raw agent JSON to leak to the caller. 1. AgentProviderExtensions.InvokeAgentAsync forced autoSend to true whenever the agent ran on the workflow conversation, which overrode the explicit autoSend: false declared in workflow.yaml and streamed the raw structured-output JSON straight to the user. Honor the caller-supplied autoSend instead. 2. IWorkflowContextExtensions.ReadState / QueueStateUpdateAsync / QueueStateResetAsync took the variable name and namespace alias directly from PropertyPath.VariableName / NamespaceAlias. Against Microsoft.Agents.ObjectModel 2026.2.4.1 those properties return null for a dotted reference such as `Local.Triage` even when SegmentCount == 2 and IsValid == true, so every assignment threw ArgumentNullException via Throw.IfNull. Fall back to Segments() to reconstruct the name and alias when the parser returns null. 3. The same ObjectModel version no longer recognizes the user-facing `Local` scope alias: VariableScopeNames.IsValidName(`Local`) returns false and GetNamespaceFromName(`Local`) returns Unknown, so the declarative interpreter's IsManagedScope check fails and the State.Set call is silently skipped. Translate the `Local` alias to its canonical `Topic` form before forwarding to QueueStateUpdateAsync; WorkflowFormulaState.Bind continues to expose it as `Local` to PowerFx. Verified end-to-end against a deployed Foundry hosted agent: the declarative triage workflow now routes Technical / Billing / General inputs correctly and only the autoSend-eligible messages reach the caller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Fixes three regressions that prevented declarative workflows from running correctly as Foundry hosted agents: an autoSend override leaking raw agent JSON, a PropertyPath parsing regression in ObjectModel 2026.2.4.1, and a Local scope alias no longer being recognized.
Changes:
- Honor caller-supplied
autoSendinstead of forcing it totruefor the workflow conversation. - Fall back to
Segments()to reconstruct variable name / namespace alias whenPropertyPath.VariableName/NamespaceAliasreturn null. - Translate the user-facing
Localscope alias to its canonicalTopicform before forwarding to state-update APIs.
Show a summary per file
| File | Description |
|---|---|
| dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs | Removes the unconditional autoSend override for the workflow conversation. |
| dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs | Adds GetVariableName / GetNamespaceAlias helpers that compensate for ObjectModel regressions and remap Local → Topic. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 6
There was a problem hiding this comment.
Automated Code Review
Reviewers: 3 | Confidence: 86%
✓ Correctness
The three regression fixes are logically correct. The
autoSend |= isWorkflowConversationremoval properly honors caller intent, and bothisWorkflowConversationandworkflowConversationIdremain used at line 51. The PropertyPath workaround viaGetVariableName/GetNamespaceAliascorrectly reconstructs the variable name and alias fromSegments(), and the "Local" →VariableScopeNames.Topictranslation ensuresIsManagedScope(which relies onVariableScopeNames.IsValidName) succeds, allowingState.Setto execute on the managed path. The existing review thread already covers the key improvement areas (readability, null guarding, magic strings, TODO markers, test coverage). I found no new blocking correctness issues beyond those already flaged.
✓ Test Coverage
This PR fixes three declarative workflow regressions but introduces no new tests for the changed behavior. The
autoSendbehavioral change inAgentProviderExtensions(removingautoSend |= isWorkflowConversation) has zero test coverage — the only test exercisingInvokeAgentAsync(Workflows/InvokeAgent.cs:70) hardcodesautoSend = trueand never verifies theautoSend: false+ workflow-conversation scenario that this fix is meant to correct. TheGetVariableName/GetNamespaceAliashelper test gap was already flagged in the prior review at line 46 and is not re-raised here.
✗ Design Approach
The overall direction looks right, but the
PropertyPathregression workaround is incomplete: runtime reads/writes now reconstruct dottedLocal.*paths, while workflow initialization still drops those same variables when seding default state. That leaves a real class of declarative workflows partially broken even after this PR.
Flagged Issues
- The null-
PropertyPathworkaround is only applied inIWorkflowContextExtensions.DeclarativeWorkflowBuilderstill callsstate.Initialize(...)during startup (DeclarativeWorkflowBuilder.cs:76), andWorkflowDiagnostics.InitializeDefaultssilently skips any variable whosevariableDiagnostic.Path.VariableNameis null (WorkflowDiagnostics.cs:61-66). Since doted refs likeLocal.Triagenow produce nullVariableName, a workflow with a declared default for such a variable will still start blank. The same fallback/remapping needs to be carried into the initialization path.
Automated review by alliscode's agents
…; run approved local AIFunctions
Two regressions hit declarative workflows that use require_approval=true when
the client chains turns via previous_response_id (no conversation_id):
1. AgentFrameworkResponseHandler keyed the AgentSession store solely on
conversation_id, so when only previous_response_id was present the
StateBag (which holds ToolApprovalIdMap) was discarded after each turn.
The next turn then threw 'No approval mapping recorded for wire id ...'
in InputConverter.ConvertMcpApprovalResponse.
Fix: fall back to previous_response_id on load and to context.ResponseId
on save so the response-id chain becomes a valid session key. Conversation
id remains preferred when present.
2. InvokeFunctionToolExecutor.CaptureResponseAsync only acted on
FunctionResultContent. In the hosted Foundry path the approval response
arrives as a ToolApprovalResponseContent with no FunctionResultContent,
so the local AIFunction never ran and downstream PropertyPath/SendActivity
consumers (e.g. {Local.RefundResult}) saw empty values.
Fix: when no FunctionResultContent matches but an approved
ToolApprovalResponseContent does, look up the registered AIFunction by
name on agentProvider.Functions and invoke it with the evaluated
arguments, surfacing the result through the existing assignment path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lpers Address PR microsoft#5905 review feedback: * Move the PropertyPath VariableName/NamespaceAlias fallback and 'Local' -> 'Topic' scope remap into a shared internal PropertyPathExtensions helper. Materializes Segments() once, names the magic 'Local' alias as a const, and carries a TODO referencing the tracking issue. * Apply the same helper in WorkflowDiagnostics.InitializeDefaults so a declared default for a dotted variable like 'Local.Triage' is no longer silently skipped at workflow startup (closes the gap flagged by the reviewer: runtime ReadState/QueueStateUpdateAsync worked but state.Initialize did not). * Restore the previous strict failure mode on namespace alias by wrapping GetNamespaceAlias() in Throw.IfNull at call sites so a malformed single-segment path keeps failing fast rather than silently passing null to State.Get/Set. All 821 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Covers the autoSend regression fix: when the agent runs on the workflow conversation with autoSend=false, no AgentResponseUpdateEvent or AgentResponseEvent is added to the context. Also covers autoSend=true (events emitted) and autoSend=false on a non-workflow conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SendActivityExecutor previously only emitted the activity text via YieldOutputAsync, which the runtime converts to an AgentResponseEvent. WorkflowSession gates AgentResponseEvent behind includeWorkflowOutputsInResponse, so when a host opts out of summary outputs (the default for AsAIAgent) the SendActivity reply is silently dropped. Mirror the pattern used by AgentProviderExtensions for autoSend agent invocations: also emit an AgentResponseUpdateEvent, which WorkflowSession yields unconditionally. This makes SendActivity reliably reach chat-protocol clients without requiring includeWorkflowOutputsInResponse = true (which would also duplicate autoSend agent output). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The fallback let a session be keyed by an unbroken previous_response_id chain, but conversation_id is the right way to thread state across turns: it survives shared/branched chains (e.g. when another agent generates a response in between) and is the documented model for stateful clients. Restore conversation_id as the sole session key and rely on the client to thread it. The InvokeFunctionTool approval/local-function half of 1baf4af remains. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| internal static string? GetNamespaceAlias(this PropertyPath variablePath) | ||
| { | ||
| string? alias = variablePath.NamespaceAlias; | ||
| if (alias is null && variablePath.SegmentCount >= 2) | ||
| { | ||
| alias = variablePath.Segments().FirstOrDefault().PropertyName; | ||
| } |
| { | ||
| null => string.Empty, | ||
| string s => s, | ||
| _ => result.ToString() ?? string.Empty, |
| { | ||
| string functionName = this.GetFunctionName(); | ||
| AIFunction? function = agentProvider.Functions?.FirstOrDefault( | ||
| f => string.Equals(f.Name, functionName, System.StringComparison.Ordinal)); |
| if (function is null) | ||
| { | ||
| return new FunctionResultContent( | ||
| this.Id, | ||
| $"Function '{functionName}' is not registered with the agent provider."); | ||
| } |
| public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() => | ||
| this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false); |
| /// 2026.2.4.1 returns null for dotted refs like "Local.Triage" even when SegmentCount | ||
| /// is 2 and IsValid is true). | ||
| /// </summary> | ||
| internal static string? GetVariableName(this PropertyPath variablePath) |
There was a problem hiding this comment.
nit: Can you verify that using this fix survives checkpointing? i.e. value stored in Local scope gets rehydrated as expected after checkpointing.
Several regressions surfaced when running a declarative workflow as a Foundry hosted agent. Together they caused condition groups to fall through to
elseActions, surfaced the raw agent JSON to the caller, silently droppedSendActivityoutput, and corrupted session state across follow-up turns.1.
AgentProviderExtensions.InvokeAgentAsyncforcedautoSend: truewhenever the agent ran on the workflow conversation, overriding the explicitautoSend: falsedeclared inworkflow.yamland streaming the raw structured-output JSON straight to the user. Honor the caller-suppliedautoSendinstead.2. Variable name/namespace resolution failed on request threads.
IWorkflowContextExtensions.ReadState/QueueStateUpdateAsync/QueueStateResetAsyncandWorkflowDiagnostics.InitializeDefaultstook the variable name and namespace alias directly fromPropertyPath.VariableName/NamespaceAlias. InMicrosoft.Agents.ObjectModel 2026.2.4.1those properties are evaluated lazily againstProductContext.Current, which isAsyncLocal<T>-scoped. The workflow sets the Foundry product on the build thread viaWorkflowFormulaState''s ctor, but when the workflow is hosted (AsAIAgent+AddFoundryResponses) each HTTP request runs on a fresh logical context where thatAsyncLocalis in its default state. The parser then returnsnullfor a dotted reference such asLocal.Triage(despiteSegmentCount == 2andIsValid == true), so every assignment threwArgumentNullExceptionviaThrow.IfNull, andIsManagedScopereturned false so the underlyingState.Setcall was silently skipped. In-process declarative samples are unaffected becauseBuildandInProcessExecution.RunStreamingAsyncshare one async chain, so the build-threadProductContextflows forward throughawait.Introduce a
PropertyPathExtensionshelper that reconstructs the variable name fromPropertyPath.Segments()and translates the user-facingLocalalias to its canonicalTopicform, bypassing the AsyncLocal-dependent parser. Route the affected call sites through the helper.WorkflowFormulaState.Bindcontinues to expose the namespace asLocalto PowerFx, so workflow YAML and expressions are unchanged.3.
SendActivityoutput was dropped from the streaming response.SendActivityExecutoryielded only anAgentResponseEvent, which is gated off by_includeWorkflowOutputsInResponse = falsewhen the workflow is hosted as an agent (to avoid duplicating the agent''s own autoSend stream). As a result, any clarification or hand-off text emitted viaSendActivitynever reached the caller. Emit anAgentResponseUpdateEventalongside the existing event soSendActivitytext is surfaced via the normal streaming path.4. HITL approval state was lost when local
AIFunctions ran inside the hosted agent.InvokeFunctionToolExecutor.CaptureResponseAsynconly acted onFunctionResultContent. In the hosted Foundry path the approval response arrives as aToolApprovalResponseContentwith no matchingFunctionResultContent, so the localAIFunctionnever ran and downstreamPropertyPath/SendActivityconsumers (e.g.{Local.RefundResult}) saw empty values. When noFunctionResultContentmatches but an approvedToolApprovalResponseContentdoes, look up the registeredAIFunctionby name onagentProvider.Functionsand invoke it with the evaluated arguments, surfacing the result through the existing assignment path.AgentFrameworkResponseHandlercontinues to key theAgentSessionstore solely onconversation_id. Threading session state via the response-id chain was considered but rejected: aprevious_response_idchain can legitimately weave through other agents (agent A → response R1, agent B → response R2 withprevious=R1, agent A again withprevious=R2), so the only reliable cross-turn anchor for an individual hosted agent is theconversationfield the client passes on every request. Clients that need HITL state across turns must includeconversationin each request — the in-tree samples andazd ai agent invokealready do.Verified end-to-end against a deployed Foundry hosted agent: the declarative triage workflow now routes Technical / Billing / General inputs correctly,
SendActivityclarifications stream back to the caller, approved local refund functions run and populate workflow variables, and onlyautoSend-eligible agent messages are forwarded.